Add large-payload blob auto-purge (opt-in singleton job, worker/SDK side)#758
Add large-payload blob auto-purge (opt-in singleton job, worker/SDK side)#758YunchuWang wants to merge 2 commits into
Conversation
82ae04d to
0ac2dc3
Compare
0752610 to
cab0e9a
Compare
cab0e9a to
c05b15a
Compare
Large orchestration payloads are externalized to Azure Blob Storage as `blob:v1:<container>:<blobName>` tokens. The DTS backend stores those tokens but cannot delete the backing blobs (it has no storage credentials) — only this SDK can. This adds an opt-in, whole-scheduler singleton durable entity + orchestration job (mirroring src/ExportHistory) that drains payload rows the backend has soft-deleted and deletes their blobs, then acks so the backend can hard-delete the rows. Design: - PayloadStore.DeleteAsync is virtual (default throws NotSupportedException so it is non-breaking for existing external subclasses); BlobPayloadStore overrides it to decode the token and call DeleteIfExistsAsync (idempotent). - BlobPurgeJob (TaskEntity singleton): Create is a no-op when already Active so racing client processes don't disturb the running job; Run starts a fixed-id orchestrator. - BlobPurgeJobOrchestrator (perpetual): fetch a batch of tombstones, delete the blobs with capped parallelism, ack the successful deletions (failed tokens stay tombstoned to retry), idle on a timer when empty, ContinueAsNew periodically. - ExecuteBlobPurgeJobOperationOrchestrator bridges client -> entity. - Two new unary RPCs on TaskHubSidecarService: GetTombstonedPayloads / AckPurgedPayloads (authoritative proto follow-up: microsoft/durabletask-protobuf#76). - LargePayloadStorageOptions gains AutoPurge (opt-in, default false) and PayloadPurgeBatchSize (default 500). - Client-side BlobPurgeJobStarter (IHostedService) ensures the singleton job when AutoPurge is enabled, without blocking host startup. Worker always registers the entity/orchestrators/activities so a client-enabled job has something to run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
c05b15a to
306d19f
Compare
| /// <param name="PartitionId">The backend partition that owns the payload row.</param> | ||
| /// <param name="InstanceKey">The orchestration instance key the payload belongs to.</param> | ||
| /// <param name="PayloadId">The backend identifier of the soft-deleted payload row.</param> | ||
| public sealed record PayloadPurgeAckDto(int PartitionId, long InstanceKey, long PayloadId); |
| /// <param name="InstanceKey">The orchestration instance key the payload belongs to.</param> | ||
| /// <param name="PayloadId">The backend identifier of the soft-deleted payload row.</param> | ||
| /// <param name="Token">The externalized payload token whose backing blob should be deleted.</param> | ||
| public sealed record TombstonedPayloadDto(int PartitionId, long InstanceKey, long PayloadId, string Token); |
| public override async Task<List<TombstonedPayloadDto>> GetTombstonedPayloadsAsync( | ||
| int limit, CancellationToken cancellation = default) | ||
| { | ||
| P.GetTombstonedPayloadsResponse response = await this.sidecarClient.GetTombstonedPayloadsAsync( |
There was a problem hiding this comment.
lets add limit range check, not here whenever limit is specified, > 0 and < 1000
| LargePayloadStorageOptions opts = this.options.Get(this.builderName); | ||
| if (!opts.AutoPurge) | ||
| { | ||
| this.logger.BlobPurgeDisabled(); | ||
| return Task.CompletedTask; |
There was a problem hiding this comment.
is this a defensive check? since this starter class should be conditionally di based on auto purge flag before checking here right?
| this.entityId, cancellation: cancellationToken); | ||
| return metadata is not null && metadata.State.Status == BlobPurgeJobStatus.Active; | ||
| } | ||
| catch (NotSupportedException) |
There was a problem hiding this comment.
double check please, it should be supported!
| /// The job is not running. This is the default status of a freshly initialized entity, so it is kept | ||
| /// as the zero value to avoid a brand-new entity accidentally appearing active. | ||
| /// </summary> | ||
| Stopped, |
| /// <summary> | ||
| /// The job has failed. | ||
| /// </summary> | ||
| Failed, |
There was a problem hiding this comment.
when this will fail, this should always run
| { | ||
| try | ||
| { | ||
| if (await this.IsJobActiveAsync(cancellationToken)) |
There was a problem hiding this comment.
unncessary right and cant guard against race condition?
we can ensure one entity instance for auto purge by using one unique entityt job id right?
| nameof(BlobPurgeJob.Create), | ||
| new BlobPurgeJobCreationOptions(batchSize)); | ||
|
|
||
| await this.client.ScheduleNewOrchestrationInstanceAsync( |
There was a problem hiding this comment.
just scheduled is enough?
| { | ||
| return; | ||
| } | ||
| catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) |
There was a problem hiding this comment.
did you check if its this job already exist case whether it throws exception, if not we do not want to retry
|
|
||
| async Task EnsureJobAsync(int batchSize, CancellationToken cancellationToken) | ||
| { | ||
| while (!cancellationToken.IsCancellationRequested) |
There was a problem hiding this comment.
why this is looping forever, what i want is start the job once if not exist, and there should only be one job running at any time, and if the job failed only you should retry scheduling it
| /// <param name="PurgeBatchSize"> | ||
| /// The maximum number of tombstoned payloads to request from the backend per cycle. | ||
| /// </param> | ||
| public sealed record BlobPurgeJobCreationOptions(int PurgeBatchSize); |
There was a problem hiding this comment.
really need this record just one param?
| /// <inheritdoc/> | ||
| public override async Task<List<TombstonedPayloadDto>> RunAsync(TaskActivityContext context, int input) | ||
| { | ||
| int limit = input > 0 ? input : 500; |
| /// <param name="store">The payload store used to delete blobs.</param> | ||
| /// <param name="logger">The logger instance.</param> | ||
| [DurableTask] | ||
| public class DeleteExternalBlobActivity( |
There was a problem hiding this comment.
should we delete a batch instead of one at at time per activity call
| public static IDurableTaskClientBuilder UseExternalizedPayloads( | ||
| this IDurableTaskClientBuilder builder, | ||
| Action<LargePayloadStorageOptions> configure) | ||
| { | ||
| Check.NotNull(builder); | ||
| Check.NotNull(configure); | ||
|
|
||
| builder.Services.Configure(builder.Name, configure); | ||
| builder.Services.AddSingleton<PayloadStore>(sp => | ||
| { | ||
| LargePayloadStorageOptions opts = sp.GetRequiredService<IOptionsMonitor<LargePayloadStorageOptions>>().Get(builder.Name); | ||
| return new BlobPayloadStore(opts); | ||
| }); | ||
|
|
||
| return UseExternalizedPayloadsCore(builder); |
There was a problem hiding this comment.
why we need configure blobpayloadstore in client
…er simplification - Drop the `Dto` suffix now that the payload records are first-class public types in `Microsoft.DurableTask.Client` (`TombstonedPayload`, `PayloadPurgeAck`). - Collapse the magic `500` batch-size literal into a single `BlobPurgeConstants.DefaultBatchSize` used everywhere. - Rename `BlobPurgeJobStatus.Stopped` -> `Pending` (still the zero value) and remove the dead `Failed` member (nothing ever set it; the job self-heals). - Make the perpetual orchestrator self-heal: wrap each cycle in try/catch so a transient backend/entity/activity failure logs, backs off, and continues instead of failing the orchestration and killing the eternal loop. - Ack poison tokens: `DeleteExternalBlobActivity` now returns a three-way `BlobDeleteResult` (Deleted/Discarded/Retry). Malformed tokens are discarded and acked so the backend can clear the stuck row instead of re-streaming it forever; transient failures stay tombstoned to retry. - Replace the single-value `BlobPurgeJobCreationOptions` record with a plain `int` on `BlobPurgeJob.Create`. - Guard the client fetch RPC: `GetTombstonedPayloadsAsync` throws `ArgumentOutOfRangeException` unless `0 < limit < 1000`. - Simplify `BlobPurgeJobStarter` to a fixed-instance-id fire-once: drop the entity-active pre-check and schedule the Create bridge once with a fixed instance id, retrying only until the backend is reachable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| foreach (DeleteOutcome outcome in outcomes) | ||
| { | ||
| // Acknowledge blobs that were deleted (or already gone) and poison tokens that can never succeed | ||
| // so the backend can hard-delete their rows; transient failures stay tombstoned to retry. | ||
| if (outcome.ShouldAck) | ||
| { | ||
| acks.Add(outcome.Ack); | ||
| } | ||
| } |
Summary
Large orchestration payloads are externalized to Azure Blob Storage by the
AzureBlobPayloadsextension asblob:v1:<container>:<blobName>tokens. The DTS backend stores those tokens but cannot delete the backing blobs — it has no storage credentials; only this SDK can. This PR implements the worker/SDK side of large-payload blob auto-purge.Design (opt-in singleton durable entity + orchestration job)
Instead of an always-on background stream, this mirrors the existing
src/ExportHistoryfeature: an opt-in, whole-scheduler singleton durable entity + orchestration job that drains soft-deleted payload rows the backend exposes and deletes their blobs, then acks so the backend can hard-delete the rows.PayloadStore.DeleteAsync— added as avirtualmethod (default throwsNotSupportedException, so it is non-breaking for existing external subclasses).BlobPayloadStoreoverrides it to decode the token and callDeleteIfExistsAsync(idempotent — deleting a missing blob is a no-op).BlobPurgeJob(TaskEntitysingleton) —Createis a no-op when already Active so racing client processes don't disturb the running job (intentionally softer thanExportJob.Create, which throws).Runstarts a fixed-instance-id orchestrator.BlobPurgeJobOrchestrator(perpetual) — each cycle: fetch a batch of tombstones, delete the blobs with capped parallelism (32), ack only the successful deletions (failed tokens stay tombstoned to retry), idle on a 1-minute timer when there's nothing to purge, andContinueAsNewevery 5 cycles to keep history small. Activities use a small retry policy.ExecuteBlobPurgeJobOperationOrchestrator— client → entity bridge (mirrors export).GetTombstonedPayloadsActivity,DeleteExternalBlobActivity(returnsfalse+ logs on failure so one bad token can't fail the batch),AckPurgedPayloadsActivity.DurableTaskClient(GetTombstonedPayloadsAsync/AckPurgedPayloadsAsync), overridden inGrpcDurableTaskClient— mirroring how ExportHistory addedListInstanceIdsAsync/GetOrchestrationHistoryAsync. The purge activities injectDurableTaskClientdirectly; no dedicated gRPC client is needed. AzureManaged reusesGrpcDurableTaskClient, so it inherits these methods with no extra client.LargePayloadStorageOptions.AutoPurge(opt-in, defaultfalse) andPayloadPurgeBatchSize(default500).BlobPurgeJobStarter(IHostedService) ensures the singleton job whenAutoPurgeis enabled, on a background task that does not block host startup and retries until the backend is reachable. The worker always registers the entity/orchestrators/activities (not gated onAutoPurge) so a client-enabled job always has something to execute.gRPC contract
Two new unary RPCs added to
TaskHubSidecarServiceinsrc/Grpc/orchestrator_service.proto(worker is the client; wire paths/TaskHubSidecarService/GetTombstonedPayloadsand/AckPurgedPayloads):C# stubs are generated at build time by
Grpc.Tools(not committed). The authoritative proto change is a follow-up in microsoft/durabletask-protobuf#76; once it merges,src/Grpc/orchestrator_service.protoshould be re-synced from upstream (content identical to what's here). The vendored change lets this PR build/test standalone.Testing
dotnet build Microsoft.DurableTask.sln— succeeds, 0 errors.dotnet test test/AzureBlobPayloads.Tests— 11 passed (BlobPayloadStore delete/idempotency +BlobPurgeJob.Createno-op-when-Active + options defaults).dotnet test test/ExportHistory.Tests— 147 passed (shared patterns unaffected).dotnet test test/Client/Core.Tests+test/Client/Grpc.Tests— 43 + 47 passed (coreDurableTaskClient/GrpcDurableTaskClientedits).Notes / deviations
BlobPurgeJobStatus.Pendingis the0/default value (mirroringExportJobStatus.Pending=0) so a freshly initialized entity never appearsActive.RecordPurgedentity op to track a cumulativePurgedCount.Co-authored-by: Copilot App 223556219+Copilot@users.noreply.github.com